Skip to content

qwen4_exp: load the block-FP8 dense projections (fixes modelopt MIXED_PRECISION checkpoints) - #392

Open
gberasmus87 wants to merge 2 commits into
FlashML-org:mainfrom
gberasmus87:qwen4-exp-serve-block-fp8-dense
Open

qwen4_exp: load the block-FP8 dense projections (fixes modelopt MIXED_PRECISION checkpoints)#392
gberasmus87 wants to merge 2 commits into
FlashML-org:mainfrom
gberasmus87:qwen4-exp-serve-block-fp8-dense

Conversation

@gberasmus87

@gberasmus87 gberasmus87 commented Sep 5, 2026

Copy link
Copy Markdown

Rewritten against current main. #418 and #426 landed the model half of this while the PR sat, so what is left is the reader half plus one unrelated TP fix — 2 files instead of 5, and no overlap with what is already merged.

main cannot load these checkpoints today

gdn.py already builds the in_proj_qkvz + in_proj_ba split when the GDN input projection resolves to a block-FP8 scheme, and after #426 it does resolve for a modelopt MIXED_PRECISION build. The reader was never taught the other half: it still fuses all four in_proj parts into one buffer. On lovedheart/Qwen3.8-Flash-Next-NVFP4-FP8 (204 quantized_layers: 48 NVFP4 experts, 156 FP8_PB_WO dense) that is:

RuntimeError: Promotion for Float8 Types is not supported,
attempted to promote Float8_e4m3fn and BFloat16
  freetoken/models/qwen4_exp/weight.py:136 in _try_fuse -> torch.cat(rows, dim=0)

fa814ab, driving main's own _rename/_try_fuse over the real shards for layer 0. Nothing reaches the model, and no weight_scale_inv reaches the buffers Fp8BlockLinearMethod declares.

The split is required, not an optimization

Fp8BlockLinearMethod.create_weights raises unless every output size is a whole number of 128-row blocks. The four-way fusion's b|a half is num_v_heads rows — 96 on this model. A block-FP8 four-way in_proj cannot be built at all, which is why gdn.py splits it, matching sglang and vLLM.

What this does

The reader asks which fused attention groups are block-FP8 and, for those, emits in_proj_qkvz | in_proj_ba and fuses each fp8 group's weight_scale_inv on the same axis as its weight. Every fp8 part is a whole number of 128-row blocks (10240|6144 for qkv|z, 12288|512|512 for q|k|v), so the per-block scales concatenate exactly alongside the rows they describe.

It asks through checkpoint_quant_config — the same QuantConfig, built from the same ModelSpec name map the engine hands the model, queried with the same attribute prefixes gdn.py and attention.py use. That is what keeps the buffers the reader emits from disagreeing with the modules the model built. It matters beyond tidiness: the block-FP8 linears have no tensor-parallel variant, so a rank that downgrades has to downgrade on both sides at once.

A checkpoint that stores block-FP8 dense without declaring it per module gets no scheme, so the model builds bf16 linears for it. Those keep the existing dequantize-at-load path, and their weight_scale_inv is dropped rather than emitted into a buffer that does not exist.

Verified

Against the real checkpoint on one RTX PRO 4000 Blackwell, driving the reader over the actual shards:

emitted buffer dtype shape
layers.0.linear_attn.in_proj_qkvz.weight float8_e4m3fn (16384, 2560)
layers.0.linear_attn.in_proj_qkvz.weight_scale_inv float32 (128, 20)
layers.0.linear_attn.in_proj_ba.weight bfloat16 (96, 2560)
layers.11.self_attn.qkv_proj.weight float8_e4m3fn (13312, 2560)
layers.11.self_attn.qkv_proj.weight_scale_inv float32 (104, 20)
layers.0.linear_attn.out_proj.weight float8_e4m3fn (2560, 6144)
layers.0.linear_attn.out_proj.weight_scale_inv float32 (20, 48)

16384/128 == 128 and 2560/128 == 20 scale rows/cols; 13312/128 == 104. No four-way in_proj is emitted, no scale is emitted for the bf16 in_proj_ba, no fusion is left buffered, and the shared expert and HC projections stay bf16 (they are on the modelopt ignore list). The declaration probe answers True for in_proj_qkv/in_proj_z/out_proj/q_proj/o_proj and False for in_proj_b/in_proj_a/shared_expert.gate_proj/input_mix_weight_down.

tests/models/qwen4_exp/: 42 failed, 54 passed, 51 skipped on this branch and the identical failure set at the fa814ab merge-base — every one a CUDA OOM or a missing optional import on a box whose single 24 GB card is 23.6 GB occupied serving a model. No regressions.

Deliberately breaking the scale fusion, and separately restoring the four-way table, both turn the checks above red, so they are not passing vacuously.

Decode numbers from the earlier form of this PR still describe the benefit of serving these natively rather than dequantizing — 27.43 → 34.20 tok/s here, and @gdevenyi independently measured 58.4 → 69.9 (+19.7%) at TP=1 on 2 x RTX 6000 Ada with residency 36.6% → 40.5%. They are no longer a before/after against main, because main has no dequantize path for a declared checkpoint; it raises.

Second commit is independent

qwen4_exp is the only family building its attention o_proj as LinearReplicated; llama, gpt_oss and minimax_m2 all use LinearOProj. That is correct at TP=1 and silently wrong under TP>1 — qkv_proj is column-parallel, so o_proj needs the sharded input dim and an all-reduce, and a missing reduction still decodes to fluent text. LinearOProj degenerates to exactly LinearReplicated at TP=1 (div_even(x, 1) == x, all-reduce skipped), so it is a no-op today and correct when #385 lands. Raised by @gdevenyi against the earlier form of this PR; split out so it can be dropped without touching the reader fix.

Gone from the previous version

The config detection, the attn_quant/dense_quant plumbing, the quant_linear.py factories and the attention.py quant wiring are all superseded by #418 and #426 and are not in this diff.

@gdevenyi

gdevenyi commented Sep 5, 2026

Copy link
Copy Markdown

Ran this on 2 x RTX 6000 Ada (sm_89, 48 GB each, PCIe 4 x16, no NVLink) with the lovedheart NVFP4-FP8 (modelopt MIXED_PRECISION) checkpoint, offload backend, KV pool 262,144 tokens, pinned PLE, 8 running requests, one card (TP=1) unless stated. Tree: main af71ba4 + TP (#385) + load-time per-tensor FP8 (#389) + this PR cherry-picked. Same harness as my other PR feedback: single-stream = median of three 256-token generations, aggregate = eight concurrent 256-token requests, TTFT on a ~1k-token prompt, residency = expert slots / 24,576, probe = the same 8-question greedy smoke test (thinking off).

build, TP=1 on one card single-stream 8 concurrent residency TTFT 1k probe
#320 (dequantize to bf16) 58.4 tok/s 135.9 tok/s 36.6% 2.04 s 7/8
this PR (native block-FP8) 69.9 (+19.7%) 150.2 (+10.5%) 40.5% 1.92 s 7/8
#389 load-time per-tensor FP8 on the same checkpoint (dequantize, re-quantize per tensor, _scaled_mm W8A8) 71.7 (+22.7%) 153.4 (+12.9%) 40.4% 1.94 s 7/8

So the win reproduces on Ada at TP=1, and it is the residency effect you describe: +3.9 points of experts on the card. In isolation the Triton block-FP8 GEMV is slower than bf16 cuBLAS on this card (3.78 vs 3.20 ms per 48-layer decode step at M=1, the micro-benchmark in #389), but the freed 2.5 GiB buys more than that costs. Per-tensor _scaled_mm edges it out by 2-3% at the same residency because that GEMM is about 2x faster than the block kernel on sm_89.

At TP=2 (both cards, 16 running, vision tower loaded) the block-FP8 linears have no parallel variant, so I carry this PR on my deploy branch with a precedence rule: FREETOKEN_FP8_DENSE=1 dequantizes and re-quantizes per tensor (TP-capable); otherwise native block-FP8 at TP=1 (this PR); otherwise bf16 under TP>1. Lovedheart at TP=2 that way: 99.8 tok/s single-stream, 336.7 at 8 concurrent, 95.8% residency, TTFT 0.81 s, the same as the RadixArk NVFP4 build with the same flag (99.2-99.6 / 331.9). Probe 6/8 at TP=2. The 6/8 at TP=2 is the prime-count question (primes in 10..30, answered 5): the RadixArk build also misses it at TP=2 in bf16 and in FP8, the bf16-dequant lovedheart run at TP=2 scored 7/8, and every TP=1 run scored 7/8. It is a borderline question that flips with the numeric path (TP=2 reduction order, FP8), and this is a one-question smoke test, not a perplexity measurement. Greedy outputs against the bf16-dequant run: the 1k-token prompt is identical for both FP8 routes at TP=1; the two short prompts diverge after 13-79 words, against a run-to-run floor of ~84 words / identical / identical on this box.

Two notes on the diff from that merge: (1) routing the bf16 fallback of attention.py through make_col_merged / make_replicated drops the TP-aware classes (local_output_sizes, row-parallel o_proj) that #385 needs; in my merge the factories are used only for the fp8_block branch. (2) _dense_is_block_fp8(model_path) in weight.py and parse_config read the declaration independently; when a rank has to downgrade (TP>1) the two must agree, so I resolve both from one helper (dense_quant_mode) after the TP info is set. Branch: gdevenyi/FreeToken deploy/chatdnp (5d97d3a).

@gdevenyi

gdevenyi commented Sep 5, 2026

Copy link
Copy Markdown

Thanks for taking both points. The dense_quant_mode consolidation matches what I carry on the deploy branch, and resolving it through one helper with try_get_tp_info is the right call — a rank has to build and load the same mode or the buffers do not match.

One thing to flag on point 1, not a problem with this PR as it stands but a hazard when it meets #385.

The new bf16 branch in models/qwen4_exp/attention.py hardcodes:

self.o_proj = LinearReplicated(self.qo_attn_dim, config.hidden_size, has_bias=False)

That is exactly what main does today, so this PR is correct on its own. But it is also the precise path a rank takes under TP>1 — the one your commit message calls out — and there LinearReplicated is wrong in three ways at once. qkv_proj is column-parallel, so each rank's attention output is its local head slice [T, local_qo_dim], not the full qo_attn_dim; o_proj therefore has to take the sharded input dim and all-reduce the partial sums afterwards. LinearReplicated keeps the full [hidden, qo_attn_dim] weight, expects the unsharded input, and performs no reduction. The loader already assumes the row-parallel layout: _shard puts o_proj on dim 1.

On my deploy branch the same else branch reads:

self.qkv_proj = LinearColParallelMerged(
    config.hidden_size, qkv_sizes, has_bias=False, local_output_sizes=self._qkv_split
)
self.o_proj = LinearOProj(self.qo_attn_dim, config.hidden_size, has_bias=False)

LinearOProj divides the input size by tp_info.size and all-reduces after the GEMM, and it degenerates to LinearReplicated's behaviour at TP=1 (div_even(x, 1) == x, the all-reduce is skipped when tp_size == 1), so it is safe for main as it stands. The one caveat is that it calls get_tp_info() in __init__, which raises when no engine has set TP info — the same reason you reached for try_get_tp_info in dense_quant_mode. So it is not a drop-in for tooling paths that build a model with no engine.

I am not asking you to change anything here; #385 is mine and the merge is my problem. Flagging it so whichever of the two lands second does not silently keep the LinearReplicated line, because the failure is quiet: the shapes only disagree once local_output_sizes shortens the qkv split, and a missing all-reduce gives each rank a partial sum that still decodes to fluent-looking text.

Also worth knowing, since your commit message mentions the two cannot be tested together on your box: the combination is running here. deploy/chatdnp carries #385 + #386 + #389 + this PR + #354, TP=2 on 2 x RTX 6000 Ada, and both attention branches are exercised — fp8_dynamic in production and the bf16 fallback whenever FREETOKEN_FP8_DENSE=0. If a TP=2 run of a specific case would help, say which and I will post it.

🤖 Generated with Claude Code

https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt

@gberasmus87

Copy link
Copy Markdown
Author

Thanks, that's a fair flag and cheaper to fix now than to remember at merge time. Swapped in 2b2a96e: the bf16 branch now builds o_proj as LinearOProj, same as your deploy branch. I read the class before doing it and agree it's a no-op at TP=1: div_even(x, 1) == x and the all-reduce is skipped, so the weight shape and the GEMM are identical to LinearReplicated. The comment above that branch also promised a row-parallel o_proj the code didn't build, so it now says what's there.

On the get_tp_info() caveat: it doesn't add a constraint here, because the same else branch already calls it two lines up through LinearColParallelMerged. That path was engine-only before and still is. Config parsing is the no-engine path that mattered, and that stays on try_get_tp_info. I've left local_output_sizes to #385, since main's LinearColParallelMerged doesn't take it, and likewise the forward's reshape(-1, self.qo_attn_dim) on the gate, which is the other full-width assumption in this file and belongs with whoever owns the local head count.

Verified at TP=1 on the Blackwell box: the four qwen4_exp test files that build this layer (test_config, test_weight, test_skeleton, test_qsa_backend) are 49/49 at 72773b0 and 49/49 with the change, including test_qsa_layer_matches_hf_dense, which runs the whole layer on the card against the HF dense reference. A direct check on CPU and CUDA confirms the built o_proj is a LinearOProj with weight [hidden, qo_attn_dim] and a forward bit-identical to LinearReplicated carrying the same weight. One correction to my last commit message while I'm here: the 47 failures I put down to VRAM contention were, for these four files at least, a missing ninja on PATH in my throwaway venv. With that fixed they pass in full.

I'll take you up on the TP=2 run. The case I can't exercise is exactly this one, the bf16 fallback under TP, so: deploy/chatdnp with this commit, FREETOKEN_FP8_DENSE=0, TP=2, your usual single-stream and the 8-question probe, with your fp8_dynamic production numbers as the reference. If the probe is still 7/8 and the rate lands where the fallback did before, that's the missing all-reduce ruled out rather than merely fluent.

FlashML-org#426 taught parse_config to read the checkpoint's QuantConfig, so a modelopt
MIXED_PRECISION build that declares its dense attention and GDN projections
FP8_PB_WO now resolves to a block-FP8 scheme, and gdn.py builds the
in_proj_qkvz + in_proj_ba split for it. The reader was never taught the other
half. It still fuses all four in_proj parts into one buffer, so on those
checkpoints it cats fp8 qkv|z with bf16 b|a and dies in torch.cat before the
model sees anything, and no weight_scale_inv ever reaches the buffers
Fp8BlockLinearMethod declares.

The four-way fusion cannot survive block-FP8 in any case: b|a are num_v_heads
rows wide, and create_weights requires every output size to be a whole number
of 128-row blocks. The split is a requirement, not an optimization.

So the reader now asks which fused attention groups are block-FP8 and emits
in_proj_qkvz | in_proj_ba (and q|k|v -> qkv_proj) for those, fusing each
group's weight_scale_inv on the same axis as its weight. Every fp8 part is a
whole number of 128-row blocks (10240|6144 for qkv|z, 12288|512|512 for q|k|v),
so the per-block scales concatenate exactly alongside the rows they describe.

It asks through checkpoint_quant_config -- the same QuantConfig, built from the
same ModelSpec name map the engine hands the model, queried with the same
attribute prefixes gdn.py and attention.py use. That is what keeps the buffers
this reader emits from disagreeing with the modules the model built. It matters
beyond tidiness: the block-FP8 linears have no tensor-parallel variant, so a
rank that downgrades has to downgrade on both sides at once.

A checkpoint that stores block-FP8 dense WITHOUT declaring it per module gets no
scheme, so the model builds plain bf16 linears for it. Those weights keep the
existing dequantize-at-load path, and their weight_scale_inv is dropped rather
than emitted into a buffer that does not exist.
qwen4_exp is the only family that builds its attention o_proj as
LinearReplicated; llama, gpt_oss and minimax_m2 all use LinearOProj. That is
correct at TP=1 and wrong under TP>1 three ways at once: qkv_proj is
column-parallel, so a rank's attention output is its local head slice rather
than the full qo_attn_dim, o_proj therefore has to take the sharded input dim,
and the partial sums need an all-reduce. LinearReplicated keeps the full
[hidden, qo_attn_dim] weight, expects the unsharded input and reduces nothing.

It also fails quietly: a missing all-reduce leaves each rank holding a partial
sum that still decodes to fluent-looking text.

LinearOProj degenerates to exactly LinearReplicated at TP=1 -- div_even(x, 1)
== x, and the all-reduce is skipped when tp_size == 1 -- so this is a no-op for
main as it stands and only changes what FlashML-org#385 finds when the two meet. It adds
no constraint from calling get_tp_info() in __init__ either, since the same
constructor already reaches it two lines up through LinearColParallelMerged.

The comment above the branch now describes what is built.

Raised by @gdevenyi against the earlier form of this work in FlashML-org#392.
@gberasmus87
gberasmus87 force-pushed the qwen4-exp-serve-block-fp8-dense branch from 2b2a96e to 295d0d6 Compare September 10, 2026 06:19
@gberasmus87 gberasmus87 changed the title qwen4_exp: serve the block-FP8 dense projections natively (+25% decode) qwen4_exp: load the block-FP8 dense projections (fixes modelopt MIXED_PRECISION checkpoints) Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants